// Tools for hosting an MTUI party over a socket server. Comparable in idea to
// telnet.js, but for interfacing over commands rather than hosting all client
// UIs on one server. The intent of the code in this file is to allow clients
// to connect and interface with each other, while still running all processes
// involved in mtui on their own machines -- so mtui will download and play
// music using each connected machine's own internet connection and speakers.

// TODO: Option to display listing items which aren't available on all
// connected devices.
//
// TODO: While having a canonical backend is useful for maintaining a baseline
// playback position and queue/library with which to sync clients, it probably
// shouldn't be necessary to have an actual JS reference to that backend.
// Making communication with the canonical backend work over socket (in as much
// as possible the same way we do current socket communication) means the
// server can be run on a remote host without requiring access to the music
// library from there. This would be handy for people with a VPN with its own
// hostname and firewall protections!

// single quotes & no semicolons time babey

import EventEmitter from 'node:events'
import net from 'node:net'

import shortid from 'shortid'

import {
  getTimeStringsFromSec,
  parseWithoutPrototype,
  silenceEvents,
} from './general-util.js'

import {
  parentSymbol,
  updateGroupFormat,
  updateTrackFormat,
  isTrack,
  isGroup,
} from './playlist-utils.js'

import {
  restoreBackend,
  restoreNewItem,
  saveBackend,
  saveItemReference,
  updateRestoredTracksUsingPlaylists,
} from './serialized-backend.js'

// This is expected to be the same across both the client and the server.
// There will probably be inconsistencies between sender clients and receiving
// clients / the server otherwise.
const DEFAULT_NICKNAME = '(Unnamed)'

export const originalSymbol = Symbol('Original item')

function serializePartySource(item) {
  // Turn an item into a sanitized, compact format for sharing with the server
  // and other sockets in the party.
  //
  // TODO: We'll probably need to assign a unique ID to the root item, since
  // otherwise we don't have a way to target it to un-share it.

  if (isGroup(item)) {
    return [item.name, ...item.items.map(serializePartySource).filter(Boolean)]
  } else if (isTrack(item)) {
    return item.name
  } else {
    return null
  }
}

function deserializePartySource(source, parent = null) {
  // Reconstruct a party source into the ordinary group/track format.

  const recursive = source => {
    if (Array.isArray(source)) {
      return {name: source[0], items: source.slice(1).map(recursive).filter(Boolean)}
    } else if (typeof source === 'string') {
      return {name: source, downloaderArg: '-'}
    } else {
      return null
    }
  }

  const top = recursive(source)

  const item = (isGroup(top)
    ? updateGroupFormat(top)
    : updateTrackFormat(top))

  if (parent) {
    item[parentSymbol] = parent
  }

  return item
}

function serializeCommandToData(command) {
  // Turn a command into a string/buffer that can be sent over a socket.
  return JSON.stringify(command)
}

function deserializeDataToCommand(data) {
  // Turn data received from a socket into a command that can be processed as
  // an action to apply to the mtui backend.
  return parseWithoutPrototype(data)
}

function namePartySources(nickname) {
  return `Party Sources - ${nickname}`
}

function isItemRef(ref) {
  if (ref === null || typeof ref !== 'object') {
    return false
  }

  // List of true/false/null. False means *invalid* reference data; null
  // means *nonpresent* reference data. True means present and valid.
  const conditionChecks = [
    'name' in ref ? typeof ref.name === 'string' : null,
    'path' in ref ? Array.isArray(ref.path) && ref.path.every(n => typeof n === 'string') : null,
    'downloaderArg' in ref ? (
      !('items' in ref) &&
      typeof ref.downloaderArg === 'string'
    ) : null,
    'items' in ref ? (
      !('downloaderArg' in ref) &&
      Array.isArray(ref.items) &&
      ref.items.every(isItemRef)
    ) : null
  ]

  if (conditionChecks.includes(false)) {
    return false
  }

  if (!conditionChecks.includes(true)) {
    return false
  }

  return true
}

function validateCommand(command) {
  // TODO: Could be used to validate "against" a backend, but for now it just
  // checks data types.

  if (typeof command !== 'object') {
    return false
  }

  if (!['server', 'client'].includes(command.sender)) {
    return false
  }

  switch (command.sender) {
    case 'server':
      switch (command.code) {
        case 'initialize party':
          return (
            typeof command.backend === 'object' &&
            typeof command.socketInfo === 'object' &&
            Object.values(command.socketInfo).every(info => (
              typeof info.nickname === 'string' &&
              Array.isArray(info.sharedSources)
            ))
          )
        case 'set socket id':
          return typeof command.socketId === 'string'
      }
      // No break here; servers can send commands which typically come from
      // clients too.
    case 'client':
      switch (command.code) {
        case 'announce join':
          return true
        case 'clear queue':
          return typeof command.queuePlayer === 'string'
        case 'clear queue past':
        case 'clear queue up to':
          return (
            typeof command.queuePlayer === 'string' &&
            isItemRef(command.track)
          )
        case 'distribute queue':
          return (
            typeof command.queuePlayer === 'string' &&
            isItemRef(command.topItem) &&
            (!command.opts || typeof command.opts === 'object' && (
              (
                !command.opts.how ||
                ['evenly', 'randomly'].includes(command.opts.how)
              ) &&
              (
                !command.opts.rangeEnd ||
                ['end-of-queue'].includes(command.opts.rangeEnd) ||
                typeof command.opts.rangeEnd === 'number'
              )
            ))
          )
        case 'play':
          return (
            typeof command.queuePlayer === 'string' &&
            isItemRef(command.track)
          )
        case 'queue':
          return (
            typeof command.queuePlayer === 'string' &&
            isItemRef(command.topItem) &&
            (
              isItemRef(command.afterItem) ||
              [null, 'FRONT'].includes(command.afterItem)
            ) &&
            (!command.opts || typeof command.opts === 'object' && (
              (
                !command.opts.movePlayingTrack ||
                typeof command.opts.movePlayingTrack === 'boolean'
              )
            ))
          )
        case 'restore queue':
          return (
            typeof command.queuePlayer === 'string' &&
            Array.isArray(command.tracks) &&
            command.tracks.every(track => isItemRef(track)) &&
            ['shuffle'].includes(command.why)
          )
        case 'seek to':
          return (
            typeof command.queuePlayer === 'string' &&
            typeof command.time === 'number'
          )
        case 'set nickname':
          return (
            typeof command.nickname === 'string' &&
            typeof command.oldNickname === 'string' &&
            command.nickname.length >= 1 &&
            command.nickname.length <= 12
          )
        case 'set pause':
          return (
            typeof command.queuePlayer === 'string' &&
            typeof command.paused === 'boolean' &&
            (
              typeof command.startingTrack === 'boolean' &&
              command.sender === 'server'
            ) || !command.startingTrack
          )
        case 'added queue player':
          return (
            typeof command.id === 'string'
          )
        case 'share with party':
          return (
            typeof command.item === 'string' ||
            Array.isArray(command.item)
          )
        case 'status':
          return (
            command.status === 'done playing' ||
            (
              command.status === 'ready to resume' &&
              typeof command.queuePlayer === 'string'
            ) ||
            command.status === 'sync playback'
          )
        case 'stop playing':
          return typeof command.queuePlayer === 'string'
        case 'unqueue':
          return (
            typeof command.queuePlayer === 'string' &&
            isItemRef(command.topItem)
          )
      }
      break
  }

  return false
}

function perLine(handleLine) {
  // Wrapper function to run a callback for each line provided to the wrapped
  // callback. Maintains a "partial" variable so that a line may be broken up
  // into multiple chunks before it is sent. Also supports handling multiple
  // lines (including the conclusion to a previously received partial line)
  // being received at once.

  let partial = ''
  return data => {
    const text = data.toString()
    const lines = text.split('\n')
    if (lines.length === 1) {
      partial += text
    } else {
      handleLine(partial + lines[0])
      for (const line of lines.slice(1, -1)) {
        handleLine(line)
      }
      partial = lines[lines.length - 1]
    }
  }
}

export function makeSocketServer() {
  // The socket server has two functions: to maintain a "canonical" backend
  // and synchronize newly connected clients with the relevent data in this
  // backend, and to receive command data from clients and relay this to
  // other clients.
  //
  // makeSocketServer doesn't actually start the server listening on a port;
  // that's the responsibility of the caller (use server.listen()).

  const server = new net.Server()
  const socketMap = Object.create(null)

  // Keeps track of details to share with newly joining sockets for
  // synchronization.
  const socketInfoMap = Object.create(null)

  server.canonicalBackend = null

  // <variable> -> queue player id -> array: socket
  const readyToResume = Object.create(null)
  const donePlaying = Object.create(null)

  server.on('connection', socket => {
    const socketId = shortid.generate()

    const socketInfo = {
      hasAnnouncedJoin: false,
      nickname: DEFAULT_NICKNAME,

      // Unlike in client code, this isn't an array of actual playlist items;
      // rather, it's the intermediary format used when transferring between
      // client and server.
      sharedSources: []
    }

    socketMap[socketId] = socket
    socketInfoMap[socketId] = socketInfo

    socket.on('close', () => {
      if (socketId in socketMap) {
        delete socketMap[socketId]
        delete socketInfoMap[socketId]
      }
    })

    socket.on('data', perLine(line => {
      // Parse data as a command and validate it. If invalid, drop this data.

      let command
      try {
        command = deserializeDataToCommand(line)
      } catch (error) {
        return
      }

      command.sender = 'client'
      command.senderSocketId = socketId
      command.senderNickname = socketInfo.nickname

      if (!validateCommand(command)) {
        return
      }

      // If the socket hasn't announced its joining yet, it only has access to
      // a few commands.

      if (!socketInfo.hasAnnouncedJoin) {
        if (![
          'announce join',
          'set nickname'
        ].includes(command.code)) {
          return
        }
      }

      // If it's a status command, respond appropriately, and return so that it
      // is not relayed.

      if (command.code === 'status') {
        switch (command.status) {
          case 'done playing': {
            const doneSockets = donePlaying[command.queuePlayer]
            if (doneSockets && !doneSockets.includes(socketId)) {
              doneSockets.push(socketId)
              if (doneSockets.length === Object.keys(socketMap).length) {
                // determine next track
                for (const socket of Object.values(socketMap)) {
                  // play next track
                }
                delete donePlaying[command.queuePlayer]
              }
            }
            break
          }
          case 'ready to resume': {
            const readySockets = readyToResume[command.queuePlayer]
            if (readySockets && !readySockets.includes(socketId)) {
              readySockets.push(socketId)
              if (readySockets.length === Object.keys(socketMap).length) {
                for (const socket of Object.values(socketMap)) {
                  socket.write(serializeCommandToData({
                    sender: 'server',
                    code: 'set pause',
                    queuePlayer: command.queuePlayer,
                    startingTrack: true,
                    paused: false
                  }) + '\n')
                  donePlaying[command.queuePlayer] = []
                }
                delete readyToResume[command.queuePlayer]
              }
            }
            break
          }
          case 'sync playback':
            for (const QP of server.canonicalBackend.queuePlayers) {
              if (QP.timeData) {
                socket.write(serializeCommandToData({
                  sender: 'server',
                  code: 'seek to',
                  queuePlayer: QP.id,
                  time: QP.timeData.curSecTotal
                }) + '\n')
                socket.write(serializeCommandToData({
                  sender: 'server',
                  code: 'set pause',
                  queuePlayer: QP.id,
                  startingTrack: true,
                  paused: QP.player.isPaused
                }) + '\n')
              }
            }
            break
        }
        return
      }

      // If it's a 'play' command, set up a new readyToResume array.

      if (command.code === 'play') {
        readyToResume[command.queuePlayer] = []
      }

      // If it's a 'set nickname' command, save the nickname.
      // Also attach the old nickname for display in log messages.

      if (command.code === 'set nickname') {
        command.oldNickname = socketInfo.nickname
        command.senderNickname = socketInfo.nickname
        socketInfo.nickname = command.nickname
      }

      // If it's a 'share with party' command, keep track of the item being
      // shared, so we can synchronize newly joining sockets with it.

      if (command.code === 'share with party') {
        const { sharedSources } = socketInfoMap[socketId]
        sharedSources.push(command.item)
      }

      // If it's an 'announce join' command, mark the variable for this!

      if (command.code === 'announce join') {
        socketInfo.hasAnnouncedJoin = true;
      }

      // If the socket hasn't announced its joining yet, don't relay the
      // command. (Since hasAnnouncedJoin gets set above, 'announce join'
      // will pass this condition.)

      if (!socketInfo.hasAnnouncedJoin) {
        return
      }

      // Relay the command to client sockets besides the sender.

      const otherSockets = Object.values(socketMap).filter(s => s !== socket)

      for (const socket of otherSockets) {
        socket.write(serializeCommandToData(command) + '\n')
      }
    }))

    const savedBackend = saveBackend(server.canonicalBackend)

    for (const qpData of savedBackend.queuePlayers) {
      if (qpData.playerInfo) {
        qpData.playerInfo.isPaused = true
      }
    }

    socket.write(serializeCommandToData({
      sender: 'server',
      code: 'set socket id',
      socketId
    }) + '\n')

    socket.write(serializeCommandToData({
      sender: 'server',
      code: 'initialize party',
      backend: savedBackend,
      socketInfo: socketInfoMap
    }) + '\n')
  })

  return server
}

export function makeSocketClient() {
  // The socket client connects to a server and sends/receives commands to/from
  // that server. This doesn't actually connect the socket to a port/host; that
  // is the caller's responsibility (use client.socket.connect()).

  const client = new EventEmitter()
  client.socket = new net.Socket()
  client.nickname = DEFAULT_NICKNAME
  client.socketId = null // Will be received from server.

  client.sendCommand = function(command) {
    const data = serializeCommandToData(command)
    client.socket.write(data + '\n')
    client.emit('sent command', command)
  }

  client.socket.on('data', perLine(line => {
    // Same sort of "guarding" deserialization/validation as in the server
    // code, because it's possible the client and server backends mismatch.

    let command
    try {
      command = deserializeDataToCommand(line)
    } catch (error) {
      return
    }

    if (!validateCommand(command)) {
      return
    }

    client.emit('command', command)
  }))

  return client
}

export function attachBackendToSocketClient(backend, client) {
  // All actual logic for instances of the mtui backend interacting with each
  // other through commands lives here.

  let hasAnnouncedJoin = false

  const sharedSources = {
    name: namePartySources(client.nickname),
    isPartySources: true,
    items: []
  }

  const socketInfoMap = Object.create(null)

  const getPlaylistSources = () =>
    sharedSources.items.map(item => item[originalSymbol])

  backend.setHasAnnouncedJoin(false)
  backend.setAlwaysStartPaused(true)
  backend.setWaitWhenDonePlaying(true)

  function logCommand(command) {
    const nickToMessage = nickname => `\x1b[32;1m${nickname}\x1b[0m`
    const itemToMessage = item => `\x1b[32m"${item.name}"\x1b[0m`

    let senderNickname = command.sender === 'server' ? 'the server' : command.senderNickname
    // TODO: This should use a unique sender ID, provided by the server and
    // corresponding to the socket. This could be implemented into the UI!
    // But also, right now users can totally pretend to be the server by...
    // setting their nickname to "the server", which is silly.
    const sender = senderNickname

    let actionmsg = `sent ${command.code} (no action message specified)`
    let code = command.code
    let mayCombine = false
    let isVerbose = false

    switch (command.code) {
      case 'announce join':
        actionmsg = `joined the party`
        break
      case 'clear queue':
        actionmsg = 'cleared the queue'
        break
      case 'clear queue past':
        actionmsg = `cleared the queue past ${itemToMessage(command.track)}`
        break
      case 'clear queue up to':
        actionmsg = `cleared the queue up to ${itemToMessage(command.track)}`
        break
      case 'distribute queue':
        actionmsg = `distributed ${itemToMessage(command.topItem)} across the queue ${command.opts.how}`
        break
      case 'initialize party':
        return
      case 'play':
        actionmsg = `started playing ${itemToMessage(command.track)}`
        break
      case 'queue': {
        let afterMessage = ''
        if (isItemRef(command.afterItem)) {
          afterMessage = ` after ${itemToMessage(command.afterItem)}`
        } else if (command.afterItem === 'FRONT') {
          afterMessage = ` at the front of the queue`
        }
        actionmsg = `queued ${itemToMessage(command.topItem)}` + afterMessage
        break
      }
      case 'restore queue':
        if (command.why === 'shuffle') {
          actionmsg = 'shuffled the queue'
        }
        break
      case 'share with party':
        // TODO: This isn't an outrageously expensive operation, but it still
        // seems a little unnecessary to deserialize it here if we also do that
        // when actually processing the source?
        actionmsg = `shared ${itemToMessage(deserializePartySource(command.item))} with the party`
        break
      case 'seek to':
        // TODO: the second value here should be the duration of the track
        // (this will make values like 0:0x:yy / 1:xx:yy appear correctly)
        actionmsg = `seeked to ${getTimeStringsFromSec(command.time, command.time).timeDone}`
        mayCombine = true
        break
      case 'set nickname':
        actionmsg = `updated their nickname (from ${nickToMessage(command.oldNickname)})`
        senderNickname = command.nickname
        break
      case 'set socket id':
        return
      case 'set pause':
        if (command.paused) {
          actionmsg = 'paused the player'
        } else {
          actionmsg = 'resumed the player'
        }
        break
      case 'stop playing':
        actionmsg = 'stopped the player'
        break
      case 'unqueue':
        actionmsg = `removed ${itemToMessage(command.topItem)} from the queue`
        break
      case 'added queue player':
        actionmsg = `created a new playback queue`
        break
      case 'status':
        isVerbose = true
        switch (command.status) {
          case 'ready to resume':
            actionmsg = `is ready to play!`
            break
          case 'done playing':
            actionmsg = `has finished playing`
            break
          case 'sync playback':
            actionmsg = `synced playback with the server`
            break
          default:
            actionmsg = `sent status "${command.status}"`
            break
        }
        break
    }
    const text = `${nickToMessage(senderNickname)} ${actionmsg}`
    backend.showLogMessage({
      text,
      code,
      sender,
      mayCombine,
      isVerbose
    })
  }

  client.on('sent command', command => {
    command.senderNickname = client.nickname
    logCommand(command)
  })

  client.on('command', async command => {
    logCommand(command)
    switch (command.sender) {
      case 'server':
        switch (command.code) {
          case 'set socket id':
            client.socketId = command.socketId
            socketInfoMap[command.socketId] = {
              nickname: client.nickname,
              sharedSources
            }
            backend.loadSharedSources(command.socketId, sharedSources)
            return
          case 'initialize party':
            for (const [ socketId, info ] of Object.entries(command.socketInfo)) {
              const nickname = info.nickname

              const sharedSources = {
                name: namePartySources(nickname),
                isPartySources: true
              }

              sharedSources.items = info.sharedSources.map(
                item => deserializePartySource(item, sharedSources))

              socketInfoMap[socketId] = {
                nickname,
                sharedSources
              }

              backend.loadSharedSources(socketId, sharedSources)
            }
            await restoreBackend(backend, command.backend)
            attachPlaybackBackendListeners()
            // backend.on('QP: playing', QP => {
            //   QP.once('received time data', () => {
            //     client.sendCommand({code: 'status', status: 'sync playback'})
            //   })
            // })
            return
        }
        // Again, no break. Client commands can come from the server.
      case 'client': {
        let QP = (
          command.queuePlayer &&
          backend.queuePlayers.find(QP => QP.id === command.queuePlayer)
        )

        switch (command.code) {
          case 'announce join': {
            const sharedSources = {
              name: namePartySources(command.senderNickname),
              isPartySources: true,
              items: []
            }
            socketInfoMap[command.senderSocketId] = {
              nickname: command.senderNickname,
              sharedSources
            }
            backend.loadSharedSources(command.senderSocketId, sharedSources)
            return
          }
          case 'clear queue':
            if (QP) silenceEvents(QP, ['clear queue'], () => QP.clearQueue())
            return
          case 'clear queue past':
            if (QP) silenceEvents(QP, ['clear queue past'], () => QP.clearQueuePast(
              restoreNewItem(command.track, getPlaylistSources())
            ))
            return
          case 'clear queue up to':
            if (QP) silenceEvents(QP, ['clear queue up to'], () => QP.clearQueueUpTo(
              restoreNewItem(command.track, getPlaylistSources())
            ))
            return
          case 'distribute queue':
            if (QP) silenceEvents(QP, ['distribute queue'], () => QP.distributeQueue(
              restoreNewItem(command.topItem),
              {
                how: command.opts.how,
                rangeEnd: command.opts.rangeEnd
              }
            ))
            return
          case 'play':
            if (QP) {
              QP.once('received time data', data => {
                client.sendCommand({
                  code: 'status',
                  status: 'ready to resume',
                  queuePlayer: QP.id
                })
              })
              silenceEvents(QP, ['playing'], () => {
                QP.play(restoreNewItem(command.track, getPlaylistSources()))
              })
            }
            return
          case 'queue':
            if (QP) silenceEvents(QP, ['queue'], () => QP.queue(
              restoreNewItem(command.topItem, getPlaylistSources()),
              isItemRef(command.afterItem) ? restoreNewItem(command.afterItem, getPlaylistSources()) : command.afterItem,
              {
                movePlayingTrack: command.opts.movePlayingTrack
              }
            ))
            return
          case 'restore queue':
            if (QP) {
              QP.replaceAllItems(command.tracks.map(
                refData => restoreNewItem(refData, getPlaylistSources())
              ))
            }
            return
          case 'seek to':
            if (QP) silenceEvents(QP, ['seek to'], () => QP.seekTo(command.time))
            return
          case 'set nickname': {
            const info = socketInfoMap[command.senderSocketId]
            info.nickname = command.senderNickname
            info.sharedSources.name = namePartySources(command.senderNickname)
            backend.sharedSourcesUpdated(client.socketId, info.sharedSources)
            return
          }
          case 'set pause': {
            // All this code looks very scary???
            /*
            // TODO: there's an event leak here when toggling pause while
            // nothing is playing
            let playingThisTrack = true
            QP.once('playing new track', () => {
              playingThisTrack = false
            })
            setTimeout(() => {
              if (playingThisTrack) {
                if (QP) silenceEvents(QP, ['set pause'], () => QP.setPause(command.paused))
              }
            }, command.startingTrack ? 500 : 0)
            */
            silenceEvents(QP, ['set pause'], () => QP.setPause(command.paused))
            return
          }
          case 'added queue player': {
            silenceEvents(backend, ['added queue player'], () => {
              const QP = backend.addQueuePlayer()
              QP.id = command.id
            })
            return
          }
          case 'share with party': {
            const { sharedSources } = socketInfoMap[command.senderSocketId]
            const deserialized = deserializePartySource(command.item, sharedSources)
            sharedSources.items.push(deserialized)
            backend.sharedSourcesUpdated(command.senderSocketId, sharedSources)
            return
          }
          case 'stop playing':
            if (QP) silenceEvents(QP, ['playing'], () => QP.stopPlaying())
            return
          case 'unqueue':
            if (QP) silenceEvents(QP, ['unqueue'], () => QP.unqueue(
              restoreNewItem(command.topItem, getPlaylistSources())
            ))
            return
        }
      }
    }
  })

  backend.on('announce join party', () => {
    client.sendCommand({
      code: 'announce join'
    })
  })

  backend.on('share with party', item => {
    if (sharedSources.items.every(x => x[originalSymbol] !== item)) {
      const serialized = serializePartySource(item)
      const deserialized = deserializePartySource(serialized)

      deserialized[parentSymbol] = sharedSources
      deserialized[originalSymbol] = item

      sharedSources.items.push(deserialized)
      backend.sharedSourcesUpdated(client.socketId, sharedSources)

      updateRestoredTracksUsingPlaylists(backend, getPlaylistSources())

      client.sendCommand({
        code: 'share with party',
        item: serialized
      })
    }
  })

  backend.on('set party nickname', nickname => {
    let oldNickname = client.nickname
    sharedSources.name = namePartySources(nickname)
    client.nickname = nickname
    client.sendCommand({code: 'set nickname', nickname, oldNickname})
  })

  function attachPlaybackBackendListeners() {
    backend.on('QP: clear queue', queuePlayer => {
      client.sendCommand({
        code: 'clear queue',
        queuePlayer: queuePlayer.id
      })
    })

    backend.on('QP: clear queue past', (queuePlayer, track) => {
      client.sendCommand({
        code: 'clear queue past',
        queuePlayer: queuePlayer.id,
        track: saveItemReference(track)
      })
    })

    backend.on('QP: clear queue up to', (queuePlayer, track) => {
      client.sendCommand({
        code: 'clear queue up to',
        queuePlayer: queuePlayer.id,
        track: saveItemReference(track)
      })
    })

    backend.on('QP: distribute queue', (queuePlayer, topItem, opts) => {
      client.sendCommand({
        code: 'distribute queue',
        queuePlayer: queuePlayer.id,
        topItem: saveItemReference(topItem),
        opts
      })
    })

    backend.on('QP: done playing', queuePlayer => {
      client.sendCommand({
        code: 'status',
        status: 'done playing',
        queuePlayer: queuePlayer.id
      })
    })

    backend.on('QP: playing', (queuePlayer, track) => {
      if (track) {
        client.sendCommand({
          code: 'play',
          queuePlayer: queuePlayer.id,
          track: saveItemReference(track)
        })
        queuePlayer.once('received time data', data => {
          client.sendCommand({
            code: 'status',
            status: 'ready to resume',
            queuePlayer: queuePlayer.id
          })
        })
      } else {
        client.sendCommand({
          code: 'stop playing',
          queuePlayer: queuePlayer.id
        })
      }
    })

    backend.on('QP: queue', (queuePlayer, topItem, afterItem, opts) => {
      client.sendCommand({
        code: 'queue',
        queuePlayer: queuePlayer.id,
        topItem: saveItemReference(topItem),
        afterItem: saveItemReference(afterItem),
        opts
      })
    })

    function handleSeek(queuePlayer) {
      client.sendCommand({
        code: 'seek to',
        queuePlayer: queuePlayer.id,
        time: queuePlayer.time
      })
    }

    backend.on('QP: seek ahead', handleSeek)
    backend.on('QP: seek back', handleSeek)
    backend.on('QP: seek to', handleSeek)

    backend.on('QP: shuffle queue', queuePlayer => {
      client.sendCommand({
        code: 'restore queue',
        why: 'shuffle',
        queuePlayer: queuePlayer.id,
        tracks: queuePlayer.queueGrouplike.items.map(saveItemReference)
      })
    })

    backend.on('QP: toggle pause', queuePlayer => {
      client.sendCommand({
        code: 'set pause',
        queuePlayer: queuePlayer.id,
        paused: queuePlayer.player.isPaused
      })
    })

    backend.on('QP: unqueue', (queuePlayer, topItem) => {
      client.sendCommand({
        code: 'unqueue',
        queuePlayer: queuePlayer.id,
        topItem: saveItemReference(topItem)
      })
    })

    backend.on('added queue player', (queuePlayer) => {
      client.sendCommand({
        code: 'added queue player',
        id: queuePlayer.id,
      })
    })
  }
}

export function attachSocketServerToBackend(server, backend) {
  // Unlike the function for attaching a backend to follow commands from a
  // client (attachBackendToSocketClient), this function is minimalistic.
  // It just sets the associated "canonical" backend. Actual logic for
  // de/serialization lives in serialized-backend.js.
  server.canonicalBackend = backend
}
